windows 系统调用
https://www.vergiliusproject.com/
R3 调用过程
这里以 OpenProcess 为例分析 Windows API 在 3 环部分的逻辑。
1 | [User App 调用 OpenProcess] |
Kernel32
在 Kernel32.dll 中,OpenProcess 属于导出名称,实际调用的函数是 OpenProcessStub。
OpenProcessStub 函数并不直接实现核心功能,它只是一个导入表跳板(thunk)。
1 | HANDLE NTAPI OpenProcess(ACCESS_MASK dwDesiredAccess, BOOL bInheritHandle, HANDLE dwProcessId) |
kernel32.dll 是 Windows API 的“用户态兼容壳”与“历史稳定接口”。这个 DLL 本身不执行系统调用逻辑,而是提供类似路由的功能,把老 API 调用映射到 kernelbase.dll、ntdll.dll 的新实现,为 Win32 应用提供熟悉且不变的函数导出。
在 IDA 的分析中我们发现 OpenProcessStub 最终调用的是 API-MS-Win-Core-Synch-L1-1-0.dll 中导出的 OpenProcess 函数。
1 | .idata:77DE1960 ; Imports from API-MS-Win-Core-Synch-L1-1-0.dll |
但是实际调试中我们发现,OpenProcessStub 调用的是 KernelBase.dll 中的 OpenProcess 函。
这实际上是 Windows 使用了一套 API‑set(虚拟 DLL)机制 来间接映射 API 调用。API‑set 是一种“协议/契约” DLL 名称,它不是实际存在的物理 DLL,而是一个 抽象接口。程序在编译时链接到这个虚拟 DLL,运行时 loader 会根据系统的 PEB.ApiSetSchema 表将其重定向到真正实现接口的模块(通常是 kernelbase.dll 或 ntdll.dll)。
KernelBase
kernelbase.dll 是 Windows 用户态 API 的核心实现模块,承载了大部分实际 Win32 API 的逻辑。
在 KernelBase.dll 中,OpenProcess 函数调用底层的 NtOpenProcess,并根据传入的参数设置好 OBJECT_ATTRIBUTES 和 CLIENT_ID,返回目标进程的句柄。
1 | HANDLE NTAPI OpenProcess(ACCESS_MASK dwDesiredAccess, BOOL bInheritHandle, HANDLE dwProcessId) |
Ntdll
在 Ntdll.dll 中,ZwOpenProcess 函数被导出为 NtOpenProcess 和 ZwOpenProcess 函数。
Nt*和Zw*是微软 Windows 操作系统中用于表示Native API(原生系统调用接口)的两套函数前缀。在内核中,系统本身(驱动、内核模块)有时候需要访问用户态受保护的资源(比如任意进程的 handle、文件对象等)。
- 如果它还走
Nt版本,会强制执行ObCheckObjectAccess(安全访问检查);- 而
Zw*的设计就是告诉内核:“我知道我在干啥,我是受信任代码,我不需要再检查 DACL/SACL 了。”而因为用户态不能自行决定“跳过权限检查”,所以
Nt和Zw完全等价,但是为例历史兼容性,Windows 还是保留了两种前缀的函数导出名称。
ntdll.dll!ZwOpenProcess 是真正发起系统调用进 0 环的函数,该函数的实现如下:
1 | ; =========================================================== |
首先这里
eax寄存器用于传递系统调用号(SSDT 索引);0xBE表示NtOpenProcess的系统调用号,供内核用来在 SSDT 表中查找实际实现。0x7FFE0300是 Windows 的KUSER_SHARED_DATA结构中的SystemCall字段,该字段默认指向KiFastSystemCall。KUSER_SHARED_DATA是 Windows 中用于优化性能、实现 syscall 跳板等功能的结构。Windows 将该结构所在的物理页映射到每个 3 环进程地址空间以及 0 环地址空间的固定地址。- 对于用户态这个结构位于所有进程的
0x7FFE0000地址处。 - 对于内核态这个结构位于
0xFFDF0000/0xFFFFF78000000000地址处。
- 对于用户态这个结构位于所有进程的
KiFastSystemCall(SYSENTER)
KiFastSystemCall 函数实现如下:
1 | ; =========================================================== |
这个函数本质上就是执行 sysenter 指令进内核然后返回。
sysenter指令本身 不保存上下文,也不自动切换用户栈到内核栈,它依赖于寄存器中携带的参数来完成调用,其中EDX传入的是用户态栈地址,供内核作为参数或上下文恢复使用。KiFastSystemCall和KiFastSystemCallRet两个函数实际上是一个整体。也就是说执行完sysenter指令后紧接着就会执行KiFastSystemCallRet的retn指令返回。
KiIntSystemCall(INT 2E)
_KiIntSystemCall 使用的是 INT 2E 并且 EDX 指向的是参数地址而不是栈顶。
1 | ; ------------------------------------------------------------------------ |
系统调用指令
出于性能优化、兼容性和安全性考虑,Windows 在不同版本和架构中采用了不同的系统调用机制。
| 系统调用方式 | 用户态调用封装 | 内核入口函数 | 处理器架构 | 用途 / 备注 |
|---|---|---|---|---|
int 2Eh |
_KiIntSystemCall |
_KiSystemService |
x86 | 旧机制,Win9x ~ Windows XP 初期使用 |
sysenter |
_KiFastSystemCall |
_KiFastCallEntry |
x86 | Windows XP 引入的快速调用机制,默认方式 |
syscall |
内联指令(无显式封装) | _KiSystemCall64 |
x64 | Windows x64 架构的系统调用标准入口(如 Nt 函数内联) |
SYSENTER/SYSEXIT
SYSENTER 是一种 快速系统调用机制,由 Intel 在 P6(Pentium Pro)架构中引入,用于从用户态(Ring3)切换到内核态(Ring0),替代传统的 int 0x2E 中断方式,以减少中断门 IDT 的开销。
SYSENTER
SYSENTER 的行为由以下三个 MSR 控制,CPU 不会自动保存用户态上下文:
| MSR 名称 | 编号(十六进制) | 内容说明 |
|---|---|---|
IA32_SYSENTER_CS |
0x174 |
内核模式的代码段选择子(CS) |
IA32_SYSENTER_ESP |
0x175 |
内核模式栈顶指针(ESP) |
IA32_SYSENTER_EIP |
0x176 |
入口函数地址(EIP) |
MSR(Model-Specific Register,模型特定寄存器)是由 CPU 厂商(如 Intel、AMD)定义的一类特殊寄存器,用于控制和监视处理器的底层功能。这些寄存器的功能高度依赖于处理器型号和架构,因此被称为“模型特定”。
MSR(Model-Specific Register)虽然是寄存器,但不像
EAX/CR0那样有固定名字,它们通过编号(索引号)访问,这些编号是由 Intel/AMD 在官方手册中定义的。例如IA32_SYSENTER_CS(0x174)是SYSENTER设置的CS代码段。Intel 提供了
RDMSR/WRMSR用来读写 MSR 寄存器,编号通过ECX传递,读/写 64 位的值为EDX:EAX(高32位在EDX,低32位在EAX)。另外在 WinDbg 可以通过
.rdmsr/.wrmsr命令来读写 MSR 寄存器。
.rdmsr NNN:读取指定编号的 MSR(NNN 是十六进制)
1
2 0: kd> .rdmsr 174
msr[174] = 00000000`00000008
.wrmsr NNN VALUE:向 MSR 写入值(注意不要误操作系统 MSR)
1 0: kd> .wrmsr 174 00000000`00000010
在执行 SYSENTER 指令的过程中,硬件会自动完成如下操作:
EIP ←
IA32_SYSENTER_EIP:跳转到内核中指定的入口函数(KiFastCallEntry)。CS ←
IA32_SYSENTER_CS & 0xFFFC:设置代码段寄存器为 Ring 0 代码段选择子。SS ←
IA32_SYSENTER_CS + 8:设置堆栈段寄存器,必须满足 GDT 中段排列规范( 要求 GDT 中段排列满足内核代码段与内核数据段紧邻)。ESP ←
IA32_SYSENTER_ESP:切换到内核堆栈(栈顶指针)。注意
IA32_SYSENTER_ESP是全局的、CPU层面的默认值。它 不是线程级别的,不能区分哪个线程用哪个内核栈,所以必须用线程上下文中的 TSS.Esp0 重新切换到线程专属栈。当前特权级从 CPL=3 切换为 CPL=0 :切换到 Ring 0,进入内核模式。
EFLAGS.VM / IF 标志位被清除 :退出虚拟 8086 模式且关闭中断。
注意
由于没有自动保存 EFLAGS,用户态想要恢复中断、TF 状态等,需要内核中手动保存和恢复。
IF(bit 9)控制 是否允许中断(即是否响应外部硬件中断)。如果中断在进入内核后一开始就触发,可能 打断尚未完成栈切换、TrapFrame 建立、上下文保存 的早期内核初始化逻辑,这会导致系统崩溃(比如使用未初始化栈空间、破坏返回地址等)。所以 进入内核后的第一件事就是关闭中断,等内核准备好了才通过sti指令开启中断。
SYSEXIT
SYSEXIT 指令用于快速返回到特权级为 3 的用户代码。它是 SYSENTER 指令的配套指令。该指令被优化以在从特权级 0 的系统过程返回到特权级 3 的用户过程时提供最大性能。此指令必须在特权级 0 的代码中执行。
在执行 SYSEXIT 指令的过程中,硬件会自动完成如下操作:
- CS ← IA32_SYSENTER_CS + 16(代码段) :从 MSR
IA32_SYSENTER_CS中的值,加上 16(表示 Ring 3 的代码段描述符)后写入CS。 - EIP ← EDX :将
EDX的值作为返回到用户态的代码入口。 - SS ← IA32_SYSENTER_CS + 24(堆栈段) :同样基于
IA32_SYSENTER_CS,加上 24 计算 Ring3 的SS段选择子。 - ESP ← ECX :将
ECX的值作为用户态堆栈的 ESP。 - CPL ← 3 :当前特权级设置为 3,即从 Ring 0 切换到 Ring 3。
INT 2E/IRETD
这是早期 Windows(如 Windows NT4 ~ XP)采用的经典系统调用方式。
INT 2E
中断指令 INT 的功能相对于 SYSENTER 有一些不同:
- CS:EIP ←
IDT[0x2E]:跳转到内核中指定的入口函数(_KiSystemService)。 - SS←
TSS.SS0:加载内核模式的堆栈段(通常为 0x10)。 - ESP ←
TSS.ESP0:切换到内核堆栈(栈顶指针)。 - 在内核堆栈中依次压入用户模式的寄存器
SS,ESP,EFLAGS,CS,EIP(INT后的地址)。 - 当前特权级从 CPL=3 切换为 CPL=0 :切换到 Ring 0,进入内核模式。
- EFLAGS.IF / TF / NT / VM 标志位被清除 :关闭中断、退出单步调试与嵌套任务、强制退出虚拟 8086 模式。
- IF(Interrupt Flag) 被清除:防止系统调用早期被外部中断打断,避免栈未初始化就进入中断处理程序。
- TF(Trap Flag) 被清除:禁止单步调试陷阱,防止调试器干扰内核指令执行。
- NT(Nested Task) 被清除:避免因
IRETD触发任务切换(Task Gate),保持当前任务上下文。 - VM(Virtual 8086 Mode) 被清除:退出虚拟 8086 模式,进入真实的保护模式 Ring 0。
IRETD
从栈中弹出 EIP、CS、EFLAGS、ESP、SS 寄存器返回用户态。
SYSCALL / SYSRET(仅限 x64)
AMD 最先提出,Intel 后跟进,在 x64 架构中标准化。
R0 调用过程
KiFastCallEntry(SYSENTER)
清理段寄存器
因为 SYSENTER 不会设置 DS/ES/FS/GS 段寄存器,而这些寄存器在内核中仍会被用到,如果它们值非法或残留用户态设置,可能导致内核访问错误或崩溃。所以 Windows 进入内核后第一件事就是清理或初始化这些段寄存器。
1 | _KiFastCallEntry |
切换内核栈
SYSENTER 之后,CPU 处于 Ring 0,且中断被关闭,因此在当前核上执行的 DPC 栈代码是 线程安全 的 —— 不会被打断,也不会与别的线程同时访问同一个栈。
DPC 栈(Deferred Procedure Call Stack)是 Windows 操作系统中为每个处理器(CPU)分配的一块专用 内核栈空间,用于处理系统调用初期、延迟任务(DPC)、中断服务后的下半部分等非线程上下文的代码执行。
但是 DPC 栈不是为线程准备的栈,不能承载线程级的数据结构(例如 KTRAP_FRAME),并且后续的系统调用处理必须允许“线程切换”。因此这里需要将堆栈切换为 TSS.Esp0。
1 | ; ============================================================================= |
这里实际上是从 fs 对应的 KPCR 的 TSS 来定位线程的 TSS 结构地址。
1 | mov ecx, large fs:_KPCR.TSS |
保存 TRAP_FRAME
接下来 KiFastCallEntry 会在内核线程栈中构造 KTRAP_FRAME 结构,用于建立完整的“系统调用返回环境”,确保系统调用在内核态运行时能保持线程上下文、支持调试、允许中断、并准备好未来的调度。
其中 KTRAP_FRAME 结构定义如下:
1 | struct _KTRAP_FRAME |
而 TSS.Esp0 在初始状态下指向 V86Es,因此需要从 SS 寄存器开始构造。
首先依次压入 SS,ESP:
1 | push KGDT_R3_DATA OR RPL_MASK ; 压栈用户 SS(0x23) |
- 用户态的
SS是固定的 0x23,因此即使在用户态修改了SS的值,经过系统调用后也会自动修复。 ESP来自KiFastSystemCall函数保存到EDX的用户态栈顶。
关于 EFLAGS 寄存器这里要做一些特殊处理。除此之外还顺便将指向用户态栈顶的 EDX 修改为指向用户态参数。
1 | pushfd ; 压栈当前 EFLAGS(注意:此时是内核模式下已被 SYSENTER 修改过的,IF=0、VM=0) |
对于内核态的
EFLAGS:通过push 2→popfd设置为干净状态,只保留 bit1,关闭中断(IF=0),清除DF/TF/NT。EFLAGS 寄存器的 bit 1 是一个保留位,始终为 1,不可清除。
对于用户态的
EFLAGS:通过修改pushfd压栈副本中的 bit10,确保将来返回用户态时中断是打开的(IF=1)。
之后保存 CS 和 EIP。
1 | push KGDT_R3_CODE OR RPL_MASK ; 压栈用户 CS(0x1B) |
CS是和SS一样是固定的值,这里设置为 0x1B。EIP设置为USER_SHARED_DATA.SystemCallReturn,这里默认是KiFastSystemCallRet函数。
接下来填充 KTRAP_FRAME 的 ErrCode 字段为 0,表示无异常。
1 | push 0 ; 压栈填充 ErrCode 用于错误处理 |
接下来是一些非易失寄存器,这里直接保存即可。
1 | push ebp ; 保存非易失寄存器 ebp |
接下来会把 EBX 和 ESI 两个寄存器分别指向两个重要的结构。另外这里还会保存用户态的 FS(0x3B),同样是固定的值。
1 | mov ebx, PCR[PcSelfPcr] ; 获取当前处理器 KPCR 地址 |
EBX指向当前处理器的KPCR结构。由于KPCR结构体的地址位于内核FS(0x30)对应段描述符的基址中,因此这里是借助KPCR的SelfPcr字段获取的KPCR地址。1
mov ebx, large fs:_KPCR.SelfPcr
ESI指向当前线程对应的KTHREAD结构。这个结构是通过KPCR.PrcbData.CurrentThread获得的。1
mov esi, [ebx+_KPCR.PrcbData.CurrentThread]
为了防止内核执行期间访问用户态的 SEH(结构化异常处理)链,避免异常处理被用户控制的数据干扰或利用。接下来会保存用户态的异常链地址,同时清空当前线程的异常处理链,防止内核期间意外触发结构化异常时查表失败或被恶意利用。
1 | push [ebx].PcExceptionList ; 保存旧的异常链 |
这里 EBX 就是前面获取的 KPCR 结构地址:
1 | push [ebx+_KPCR.NtTib.ExceptionList] ; TRAP_FRAME.ExceptionList = KPCR.NtTib.ExceptionList |
之后会为 KTRAP_FRAME 分配剩余空间并作地址检查。
1 | ; 保存之前的模式(用户模式),并为 trap frame 分配剩余空间 |
- 设置
KTRAP_FRAME.PreviousPreviousMode和KPCR.PrcbData.CurrentThread->PreviousMode为 1 表示是来自用户态的调用。 - 比较
KTRAP_FRAME地址与KPCR.PrcbData.CurrentThread->InitialStack - sizeof(NPX_FRAME) - sizeof(KTRAP_FRAME),如果不相等则拒绝执行。KTRAP_FRAME地址是在当ESP指向KTRAP_FRAME.PreviousPreviousMode的时候有减去PreviousPreviousMode在KTRAP_FRAME的偏移得到的。KTHREAD.InitialStack指向内核栈底,而在内核栈底保存着NPX_FRAME和KTRAP_FRAME两个结构。- 如果两者不相等说明
TSS.Esp0并没有指向KTRAP_FRAME.V86Es位置,即当前线程是以 虚拟8086(V86)模式 创建的线程,具有特殊的栈结构。或者栈指针被破坏、异常栈使用错误。
Kfsc91 会抛出 #UD(Invalid Opcode,非法/未定义指令)异常。
1 | ; |
最后再初始化 KTRAP_FRAME 中调试相关字段。
1 | ; |
Dr_FastCallDrSave 函数会保存用户态的调试寄存器到 KTRAP_FRAME 结构,然后从 KPRCB.ProcessorState.SpecialRegisters 取出调试寄存器对应内核态的值赋值给调试寄存器。
1 | ; ============================================================================= |
SET_DEBUG_DATA 宏则会保存 EIP,EBP,和参数地址信息,方便我们调试的时候进行栈 0 环到 3 环的回溯。
1 | ;++ |
调用 SSDT 表函数
系统服务分发表(SSDT,System Service Dispatch Table)是 Windows 内核中的一个关键数据结构,用来将用户空间的系统调用映射到对应的内核函数。
当用户程序通过 int 0x2e(老版)或 sysenter/syscall(新版) 发起系统调用时:
- 系统调用号(Service ID)被放入
EAX。 - 内核通过
SSDT[EAX]查找到该号对应的内核函数指针并调用它。
SSDT 实际是一个结构体数组,每一项描述一个服务表(例如 Native API、Win32k GUI API等)。其核心结构如下:
1 | typedef struct _KSERVICE_TABLE_DESCRIPTOR { |
ServiceTableBase:系统服务函数表的基地址。在 x86 中,它是一个 函数地址数组,每个 DWORD 为一个系统调用的实现地址;
1
FuncAddr = KeServiceDescriptorTable.ServiceTableBase[index];
在 x64 中,它是一个 偏移值数组,每项存储的是
(TargetFunctionAddress - KiSystemCall64Base) << 4。1
2FuncAddr = ((KeServiceDescriptorTable.ServiceTableBase[index] >> 4)
+ KeServiceDescriptorTable.ServiceTableBase);
ServiceCounterTableBase:服务调用计数表的基地址。每个服务对应一个计数器,用于记录该系统服务被调用的次数。此字段通常为NULL,仅在调试版本的内核中启用。NumberOfServices:系统调用的总个数,即ServiceTableBase中可用的函数总数。系统调用前会验证调用号是否越界(EAX/RAX 是否 < 该值)。ParamTableBase:参数长度表,每项是一个字节,表示对应系统服务调用的参数总大小(单位:字节)。
Windows 系统维护一个或多个服务表(内核表、GUI 表等),统一管理在如下符号变量中:
1 | KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable; |
KeServiceDescriptorTable描述的是 ntoskrnl 表(native API),处理Nt*系列系统调用。KeServiceDescriptorTable在低版本系统中是对外导出的。KeServiceDescriptorTableShadow是一个KSERVICE_TABLE_DESCRIPTOR结构体数组,有 4 项,但通常只使用前 2 项。KeServiceDescriptorTableShadow不对外导出,但是通常位于KeServiceDescriptorTable表上方偏移 0x40 处。KeServiceDescriptorTableShadow[0]和KeServiceDescriptorTable内容相同。KeServiceDescriptorTableShadow[1]描述的是 GUI/GDI(win32k.sys)服务 SSDT,处理 GUI 调用(如NtUser*、NtGdi*)
KTHREAD.ServiceTable 会指向其中一个表,具体是哪个取决于当前线程是否是 UI 线程。
首先 Windows 会根据传入的系统调用号 EAX 提取服务表信息和服务号。
1 | ; |
查询 SSDT 表的系统调用号 EAX 不只是一个简单的索引,而是由以下部分组成:
1 | EAX (32-bit syscall number): |
即前 12 比特表示的是系统调用处理函数在 SSDT 表中的下标,而之后的 2 比特表示的是 SSDT 表在 KeServiceDescriptorTableShadow(KTHREAD.ServiceTable)中的索引。
这段代码的逻辑是:
ECX = (EAX >> 8) & 0x10,即将EDI赋值为调用号对应在KeServiceDescriptorTableShadow中的偏移。这里右移 8 实际上就是在右移 12 的基础上乘上了KSERVICE_TABLE_DESCRIPTOR的大小 0x10,而& 0x10是为了清除服务号的高 4 比特且KeServiceDescriptorTableShadow只有前 2 项有效。EDI = ECX + KTHREAD.ServiceTable,将EDI指向对应的KSERVICE_TABLE_DESCRIPTOR结构。EBX = EAX,将原始的调用号备份到EBX中。EAX = EAX & 0xFFF,取调用号的低 12 位得到服务号。
之后会根据 KSERVICE_TABLE_DESCRIPTOR.NumberOfServices 检查服务号范围是否合法。
1 | ; |
如果是 GUI 服务,并且线程的 GDI 批调用计数不为 0,则调用 KeGdiFlushUserBatch 将批量 GDI 操作刷新提交;这一步是防止内核处理一个系统调用时,用户空间的批处理操作尚未同步,确保图形一致性。
1 | ; |
之后会更新服务调用计数,用于调试分析和性能统计。
KPCR.PrcbData.KeSystemCalls++,增加当前处理器的系统调用计数。KSERVICE_TABLE_DESCRIPTOR.ServiceCounterTableBase[EAX]++,更新 SSDT 表中的服务调用次数,仅调试版中启用。
1 | Kss40: |
@@和@f是 MASM 汇编语法中的临时标签:
@@:表示一个临时标签(label),通常配合@f和@b使用。@f:表示“向前(forward)跳转”到最近定义的@@。@b:表示“向后(backward)跳转”到最近定义的@@。
服务函数的参数总是从用户栈复制到内核栈,参数长度由 SdNumber 表给出(一个字节表示每项服务的参数总大小),复制前栈空间预分配,且防止从非法内核地址读取数据。最后调用对应服务号的函数。
1 | ; |
返回用户态
调用完 SSDT 表中对应的函数后就会返回用户态。在返回用户态的过程中会有一些列的检查。
首先会检查 IRQL 等级。如果是来自用户态的系统调用则需要确保当前 IRQL 等级为 0(PASSIVE_LEVEL),否则就跳转到 kss100 调用 KeBugCheck2 蓝屏报错。因为如果 IRQL 大于 0 则意味着当前中断上下文尚未清理完,系统不允许直接返回用户态。
IRQL(Interrupt Request Level,中断请求级别)是 Windows 内核中的一种中断优先级机制,用来 控制线程调度、中断响应和同步规则。Windows 内核会根据IRQL 的高低,来决定当前 CPU 允许处理哪些中断、调度哪些线程,以及能否执行某些操作。
常见的 IRQL 等级如下:
名称 值 说明 PASSIVE_LEVEL0 普通线程运行级别(几乎所有代码运行在此) APC_LEVEL1 异步过程调用(APC)处理级别 DISPATCH_LEVEL2 调度相关(如 DPC)运行在此级别 >=3到31用于硬件中断服务例程(ISR)处理,不同设备映射到不同级别 HIGH_LEVEL31 系统最高级别(屏蔽所有中断),只允许极少代码运行
1 | kss60: |
kss100 的蓝屏代码如下:
1 | kss100: |
之后检查当前线程是否处于一种不允许从内核返回到用户模式的状态。如果发现:
- 线程附加到其他进程(如
KeStackAttachProcess创建的附加上下文); - 或当前线程禁用了 APC 调度机制(即禁止内核异步过程调用)。
就直接触发蓝屏(kss120),防止继续错误地返回到用户态。
APC(Asynchronous Procedure Call) 是 Windows 内核提供的一种机制,允许内核或用户模式的函数异步地在指定线程上下文中执行。它分为两种:
- 用户模式 APC:线程即将从内核返回用户态时,由内核触发挂起的用户 APC 回调函数。
- 内核模式 APC:在内核中异步执行某些回调(例如异步 I/O 完成通知)。
1 | ; |
在 Windows 内核中,内核 APC(Asynchronous Procedure Call)机制是安全返回用户态的重要保障。
- 如果 APC 被禁用(ApcDisable != 0),而线程又切回用户态,可能导致内核逻辑失效、死锁或内存破坏。
- 如果线程附加到另一个进程上下文(ApcStateIndex != 0),说明该线程逻辑上“借用了”另一个进程的虚拟内存视图。这种情况下强行返回用户态会让线程执行在错误的进程地址空间,是非常严重的 bug。
kss120 蓝屏代码如下:
1 | ; |
之后清理堆栈,恢复之前复制参数时抬升的堆栈。
1 | kss61: |
之后会从 TRAP_FRAME.Edx 恢复之前的 TRAP_FRAME 到 KTHREAD.TrapFrame,这一步是针对 INT 2E 指令对应的 _KiSystemService 函数进行的。而 SYSENER 指令对应的 _KiFastCallEntry 是直接返回 3 环,因此并不在乎 KTHREAD.TrapFrame 这个字段。
1 | kss70: |
之后会检查当前线程是否有挂起的用户 APC
- 若有,则派发用户 APC 并再次检查;
- 若无,则恢复 Trap Frame 中保存的用户态上下文,安全返回用户空间;
1 | _KiServiceExit proc |
在之后会恢复线程的 ExceptionList,PreviousMode 以及调试寄存器。
1 | ExitToUser: |
RestoreDebugRegisters 就是把 TRAP_FRAME 中的调试寄存器恢复到寄存器中。
1 | ; -------------------- 恢复调试寄存器 -------------------- |
最后就是根据调用者的状态分别调转到不同的返回逻辑中。如果是返回 3 环是通过 iretd 指令返回。这是因为内核在返回用户态的时候不清楚当初系统调用是通过 sysenter 还是 int 2E 完成的。
1 | ContinueReturn: |
KiSystemService(INT 2E)
INT 2E 是旧式的系统调用方式,进入内核态后会跳转到 _KiSystemService。另外很多内核态的函数实际上也是通过调用 _KiSystemService 实现的。
提示
在内核中,Zw* 函数和 Nt* 函数在功能上是一致的,但是 Zw* 函数会经过 SSDT 表,因此效率低且会被一些 SSDT 相关的 Hook 监控。
1 | ; NTSTATUS __stdcall ZwOpenProcess( |
_KiSystemService 实现比 _KiFastCallEntry 要简洁一些,主要过程是先调用 ENTER_SYSCALL 宏构造 TRAP_FRAME 结构,之后跳转到 _KiFastCallEntry 的 _KiSystemServiceRepeat 调用 SSDT 表中的处理函数然后返回。
1 | ; |
由于对于 SYSENTER 指令 CPU 什么都没压,并且还跑在 per-CPU DPC Stack;内核 prolog 必须自己构造 IRET Frame、手动换到线程栈,再建完整 TrapFrame,因而步骤更多、字段也多预留了 NPX 区。
而对于 INT 2E 指令,CPU 已经帮忙切栈、压返回态;内核 prolog 只需微调——改 FS、存非易失寄存器、挂 TrapFrame。
1 | ;++ |
_KiSystemService 的 ENTER_SYSCALL 在构造 TRAP_FRAME 时主要有如下不同点:
栈切换与返回帧构造:
- _KiSystemService(INT 2E) :CPU 在执行
INT 2E指令的时候已经完成了栈切换,并且压了EIP/CS/EFLAGS/SS/ESP因此直接从ErrCode开始构造。 - _KiFastCallEntry(SYSENTER) :MSR 指定的是 DPC Stack(per-CPU);所以内核需要先切换内核堆栈,然后再在那里建“伪 IRET 帧”。
- _KiSystemService(INT 2E) :CPU 在执行
PreviousMode 判定逻辑:
- _KiSystemService(INT 2E) :根据来时
CS.RPL动态判定,因为调用方既可能是用户态也可能是内核态。 - _KiFastCallEntry(SYSENTER) :
SYSENTER指令只能由用户态调用,因此直接强制设置为从用户态调用,另外还会在KiFastCallEntry开头“修复”用户态的段寄存器。
- _KiSystemService(INT 2E) :根据来时
嵌套保存 TRAP_FRAME:
_KiSystemService(INT 2E) :会将上一个系统调用的
TRAP_FRAME地址KTHREAD.TrapFrame保存到TRAP_FRAME.Edx然后再设置KTHREAD.TrapFrame指向当前TRAP_FRAME。这是为了支持系统调用的嵌套 / 递归(例如内核里再次发起 syscall),可通过
TrapFrame.TsEdx字段找到上一个 Trap Frame,用于 unwind 或调试工具遍历调用链。_KiFastCallEntry(SYSENTER) :
KiFastCallEntry只会设置设置KTHREAD.TrapFrame指向当前TRAP_FRAME,而不将上一个系统调用的TRAP_FRAME地址KTHREAD.TrapFrame保存到TRAP_FRAME.Edx。主要因为
SYSENTER是为 纯用户态到内核 的快速单向路径设计的,性能优先,不考虑内核中间层再发起 syscall 的情形。
SSDT Hook
1 |
|
- Title: windows 系统调用
- Author: sky123
- Created at : 2022-09-28 11:45:14
- Updated at : 2025-08-19 01:14:49
- Link: https://skyi23.github.io/2022/09/28/windows 系统调用/
- License: This work is licensed under CC BY-NC-SA 4.0.